El dataset original de Airbnb (listings.csv) cuenta con
79 variables y más de 8.000 observaciones. Gran parte
de esta información consiste en metadatos técnicos
(scrape_id), textos no estructurados
(description, urls) o redundancias que
introducen ruido en nuestro análisis.
Para abordar las tres preguntas de negocio planteadas (Segmentación por barrio, Predicción de Precio y Análisis Geoespacial), hemos ejecutado una selección estratégica de características (Feature Selection). Hemos reducido la dimensionalidad del dataset a 19 variables clave, agrupadas en cuatro dimensiones funcionales que explican la varianza del mercado:
Variables necesarias para estandarizar la comparación entre alojamientos heterogéneos.
room_type,
accommodates,
bedrooms: Definen la capacidad real y el
modelo de alojamiento (privado vs. compartido). Son los predictores base
del precio.
bathrooms_text: Seleccionada para
ingeniería de características. El número de baños es un indicador
crítico de lujo/gama alta que a menudo se omite en análisis
básicos.
amenities: Aunque es texto, la
transformaremos para cuantificar el “valor añadido” (piscina, aire
acondicionado) que justifica sobreprecios.
Variables que permiten perfilar el comportamiento del anfitrión (Profesional vs. Particular).
price: Variable objetivo principal.
Requiere limpieza de caracteres ($, ,) para su
tratamiento numérico.
availability_365: Proxy de
dedicación. Distingue entre negocios de dedicación exclusiva y
alquileres esporádicos.
calculated_host_listings_count e
instant_bookable: Métricas de
profesionalización. Un anfitrión con múltiples propiedades y reserva
inmediata tiene un perfil de riesgo y rotación distinto.
Cruciales para el Clustering y para explicar outliers de precio.
reviews_per_month: El mejor
indicador de la demanda actual y la rotación del activo.
review_scores_rating,
_cleanliness,
_location: Variables de calidad subjetiva.
Permiten detectar alojamientos “sobrevalorados” (caros pero con mala
nota) o “joyas ocultas”.
Necesarias para el análisis continuo del espacio urbano.
latitude /
longitude: Materia prima para el cálculo
de distancia euclídea/haversine a la Giralda (Pregunta 3).
neighbourhood_cleansed: Para
establecer la línea base de precios por zona administrativa (Pregunta
2).
library(tidyverse)
library(corrplot)
listings <- read.csv("./csv/listings.csv")
str(listings)
'data.frame': 8215 obs. of 79 variables:
$ id : num 49287 108236 111140 159596 179629 ...
$ listing_url : chr "https://www.airbnb.com/rooms/49287" "https://www.airbnb.com/rooms/108236" "https://www.airbnb.com/rooms/111140" "https://www.airbnb.com/rooms/159596" ...
$ scrape_id : num 2.03e+13 2.03e+13 2.03e+13 2.03e+13 2.03e+13 ...
$ last_scraped : chr "2025-09-30" "2025-09-30" "2025-09-30" "2025-09-30" ...
$ source : chr "city scrape" "city scrape" "city scrape" "previous scrape" ...
$ name : chr "BEAUTIFUL APARTMENT IN SEVILLE" "Sunny apt in heart of seville!!" "Quiet&historicenter&local experienc" "apto lujo 2 D en el Arenal (Sevilla)" ...
$ description : chr "Nice apartment on the second floor of a beautiful house in the famous Alameda square in the old center of Sevil"| __truncated__ "Clean, quiet and cosy 2-bedroom apartment near Fine Arts Museum. Queen size beds, bathroom with a bathtub and s"| __truncated__ "Nice and cozy apartment next to the Plaza del Museo de Bellas Artes. It has 1 double bedroom with large built-i"| __truncated__ "" ...
$ neighborhood_overview : chr "The famous Plaza de Hercules has changed a lot since its restoration in 2004. Its a lively neighbourhood with m"| __truncated__ "The apartment is located in a prime, picturesque area of Old Seville, just a few minutes' walk from the Museo d"| __truncated__ "The apartment is located in an unbeatable area of the old town of Seville. Next to the Plaza del Museo de Bella"| __truncated__ "It is in the middle of Seville in the luxurious neighborhood of El Arenal" ...
$ picture_url : chr "https://a0.muscache.com/pictures/7a7b22d8-5f7f-4205-8d0b-1aaa690ae9b5.jpg" "https://a0.muscache.com/pictures/796657/67ae197e_original.jpg" "https://a0.muscache.com/pictures/797042/0cfbd568_original.jpg" "https://a0.muscache.com/pictures/2021d001-7779-4213-b027-a23b7d27f0d3.jpg" ...
$ host_id : int 224697 560040 560040 629861 860055 860055 1022533 1188880 1330663 1391956 ...
$ host_url : chr "https://www.airbnb.com/users/show/224697" "https://www.airbnb.com/users/show/560040" "https://www.airbnb.com/users/show/560040" "https://www.airbnb.com/users/show/629861" ...
$ host_name : chr "Walter" "José Luis" "José Luis" "Alvaro" ...
$ host_since : chr "2010-09-05" "2011-05-05" "2011-05-05" "2011-05-26" ...
$ host_location : chr "Sevilla, Spain" "Seville, Spain" "Seville, Spain" "Sevilla, Spain" ...
$ host_about : chr "Born in Holland and moved to Sevilla in 1997\nI am the owner of a computer shop and living together with my spa"| __truncated__ "Hello!\n\nI'm a former electrical engineer. Nowadays, I like travelling, being with friends, meeting new people"| __truncated__ "Hello!\n\nI'm a former electrical engineer. Nowadays, I like travelling, being with friends, meeting new people"| __truncated__ "ME GUSTA VIAJAR PPALMENTE EUROPA CENTRAL, TB USA, CARIBE, AUSTRALIA\nME GUSTA MUCHO INTERNET" ...
$ host_response_time : chr "within an hour" "within an hour" "within an hour" "a few days or more" ...
$ host_response_rate : chr "100%" "100%" "100%" "0%" ...
$ host_acceptance_rate : chr "100%" "92%" "92%" "0%" ...
$ host_is_superhost : chr "f" "t" "t" "f" ...
$ host_thumbnail_url : chr "https://a0.muscache.com/im/users/224697/profile_pic/1351593455/original.jpg?aki_policy=profile_small" "https://a0.muscache.com/im/pictures/user/User/original/2d53da87-eaa6-4189-b76b-a1d4e82239c5.jpeg?aki_policy=profile_small" "https://a0.muscache.com/im/pictures/user/User/original/2d53da87-eaa6-4189-b76b-a1d4e82239c5.jpeg?aki_policy=profile_small" "https://a0.muscache.com/im/users/629861/profile_pic/1337773141/original.jpg?aki_policy=profile_small" ...
$ host_picture_url : chr "https://a0.muscache.com/im/users/224697/profile_pic/1351593455/original.jpg?aki_policy=profile_x_medium" "https://a0.muscache.com/im/pictures/user/User/original/2d53da87-eaa6-4189-b76b-a1d4e82239c5.jpeg?aki_policy=profile_x_medium" "https://a0.muscache.com/im/pictures/user/User/original/2d53da87-eaa6-4189-b76b-a1d4e82239c5.jpeg?aki_policy=profile_x_medium" "https://a0.muscache.com/im/users/629861/profile_pic/1337773141/original.jpg?aki_policy=profile_x_medium" ...
$ host_neighbourhood : chr "Casco Antiguo" "Casco Antiguo" "Casco Antiguo" "Casco Antiguo" ...
$ host_listings_count : int 1 4 4 2 24 24 8 2 2 7 ...
$ host_total_listings_count : int 1 10 10 2 39 39 8 4 2 18 ...
$ host_verifications : chr "['email', 'phone', 'work_email']" "['email', 'phone']" "['email', 'phone']" "['email', 'phone']" ...
$ host_has_profile_pic : chr "t" "t" "t" "t" ...
$ host_identity_verified : chr "t" "t" "t" "t" ...
$ neighbourhood : chr "Seville, AL, Spain" "Seville, Andalusia, Spain" "Seville, Andalusia, Spain" "Seville, Andalucía, Spain" ...
$ neighbourhood_cleansed : chr "San Lorenzo" "San Vicente" "San Vicente" "Arenal" ...
$ neighbourhood_group_cleansed : chr "Casco Antiguo" "Casco Antiguo" "Casco Antiguo" "Casco Antiguo" ...
$ latitude : num 37.4 37.4 37.4 37.4 37.4 ...
$ longitude : num -6 -6 -6 -6 -5.99 ...
$ property_type : chr "Entire rental unit" "Entire rental unit" "Entire condo" "Entire rental unit" ...
$ room_type : chr "Entire home/apt" "Entire home/apt" "Entire home/apt" "Entire home/apt" ...
$ accommodates : int 3 5 4 4 5 7 2 1 2 6 ...
$ bathrooms : num 1 1 1 NA 1 NA 1 1.5 1 1 ...
$ bathrooms_text : chr "1 bath" "1 bath" "1 bath" "1 bath" ...
$ bedrooms : int 1 2 1 2 3 3 1 1 1 2 ...
$ beds : int 1 3 2 NA 4 NA 1 1 1 4 ...
$ amenities : chr "[\"Bed linens\", \"Air conditioning\", \"Stove\", \"Dedicated workspace\", \"Shampoo\", \"Dishes and silverware"| __truncated__ "[\"Bed linens\", \"Lockbox\", \"Yamaha sound system with aux\", \"Carbon monoxide alarm\", \"Stainless steel ov"| __truncated__ "[\"Bed linens\", \"Lockbox\", \"Safe\", \"Dedicated workspace\", \"Shampoo\", \"Dishes and silverware\", \"Baki"| __truncated__ "[\"Bed linens\", \"Air conditioning\", \"Smoking allowed\", \"Dedicated workspace\", \"Dishes and silverware\","| __truncated__ ...
$ price : chr "$93.00" "$120.00" "$89.00" "" ...
$ minimum_nights : int 3 2 2 64 3 3 3 1 3 2 ...
$ maximum_nights : int 1125 1125 1125 330 1125 365 30 7 365 365 ...
$ minimum_minimum_nights : int 3 2 2 64 2 3 2 1 3 2 ...
$ maximum_minimum_nights : int 3 2 2 64 3 3 3 3 3 3 ...
$ minimum_maximum_nights : int 1125 1125 1125 330 1125 20 1125 7 365 365 ...
$ maximum_maximum_nights : int 1125 1125 1125 330 1125 1125 1125 7 365 365 ...
$ minimum_nights_avg_ntm : num 3 2 2 64 3 3 3 1.1 3 2 ...
$ maximum_nights_avg_ntm : num 1125 1125 1125 330 1125 ...
$ calendar_updated : logi NA NA NA NA NA NA ...
$ has_availability : chr "t" "t" "t" "t" ...
$ availability_30 : int 5 5 2 0 7 8 8 3 11 4 ...
$ availability_60 : int 18 35 19 30 22 32 29 32 38 30 ...
$ availability_90 : int 41 65 38 60 37 45 56 62 63 32 ...
$ availability_365 : int 206 79 106 60 245 252 111 62 148 32 ...
$ calendar_last_scraped : chr "2025-09-30" "2025-09-30" "2025-09-30" "2025-09-30" ...
$ number_of_reviews : int 40 222 63 1 205 120 508 238 331 459 ...
$ number_of_reviews_ltm : int 1 25 11 0 3 7 53 41 18 34 ...
$ number_of_reviews_l30d : int 1 3 0 0 0 0 6 1 1 2 ...
$ availability_eoy : int 41 68 38 60 37 46 59 62 64 32 ...
$ number_of_reviews_ly : int 0 16 10 0 13 10 56 32 35 38 ...
$ estimated_occupancy_l365d : int 6 150 66 0 18 42 255 246 108 204 ...
$ estimated_revenue_l365d : int 558 18000 5874 NA 2628 NA 21675 13284 8640 21420 ...
$ first_review : chr "2011-02-27" "2011-05-17" "2011-05-27" "2019-11-24" ...
$ last_review : chr "2025-09-21" "2025-09-23" "2025-08-02" "2019-11-24" ...
$ review_scores_rating : num 4.64 4.77 4.7 4 4.55 4.4 4.87 4.94 4.78 4.61 ...
$ review_scores_accuracy : num 4.87 4.84 4.68 4 4.59 4.6 4.93 4.92 4.85 4.72 ...
$ review_scores_cleanliness : num 4.97 4.81 4.69 5 4.77 4.73 4.95 4.96 4.68 4.72 ...
$ review_scores_checkin : num 4.92 4.88 4.87 5 4.5 4.48 4.93 4.89 4.89 4.72 ...
$ review_scores_communication : num 4.92 4.93 4.84 5 4.55 4.59 4.96 4.91 4.92 4.66 ...
$ review_scores_location : num 4.92 4.86 4.82 5 4.93 4.17 4.94 4.91 4.93 4.88 ...
$ review_scores_value : num 4.74 4.75 4.52 4 4.6 4.54 4.8 4.86 4.77 4.63 ...
$ license : chr "VFT/SE/01116" "VFT/SE/05126" "VUT/SE/08197" "" ...
$ instant_bookable : chr "t" "f" "f" "f" ...
$ calculated_host_listings_count : int 1 4 4 2 24 24 1 2 2 5 ...
$ calculated_host_listings_count_entire_homes : int 1 4 4 2 24 24 1 0 2 5 ...
$ calculated_host_listings_count_private_rooms: int 0 0 0 0 0 0 0 2 0 0 ...
$ calculated_host_listings_count_shared_rooms : int 0 0 0 0 0 0 0 0 0 0 ...
$ reviews_per_month : num 0.23 1.27 0.36 0.01 1.23 0.7 3.78 1.41 1.96 2.76 ...
Antes de proceder al preprocesamiento, realizamos una inspección técnica del dataset crudo. De esta forma podemos analizar aspectos claves como:
Duplicidad
Missing values
Consistencia
# 1. ESTRUCTURA
print("--- Dimensiones del Dataset ---")
[1] "--- Dimensiones del Dataset ---"
dim(listings) # Filas x Columnas
[1] 8215 79
# 2. CHECK DE DUPLICADOS
duplicados_totales <- sum(duplicated(listings))
duplicados_id <- sum(duplicated(listings$id))
print(paste("Filas totalmente duplicadas:", duplicados_totales))
[1] "Filas totalmente duplicadas: 0"
print(paste("IDs repetidos (mismo piso scrapeado varias veces):", duplicados_id))
[1] "IDs repetidos (mismo piso scrapeado varias veces): 0"
# 3. MISSING VALUES
na_count <- colSums(is.na(listings))
na_percent <- (na_count / nrow(listings)) * 100
# Mostrar las 10 columnas con más nulos
print("--- Top 10 Columnas con más Nulos (%) ---")
[1] "--- Top 10 Columnas con más Nulos (%) ---"
print(sort(na_percent, decreasing = TRUE)[1:10])
calendar_updated review_scores_rating review_scores_accuracy review_scores_cleanliness
100.00000 8.82532 8.82532 8.82532
review_scores_checkin review_scores_communication review_scores_location review_scores_value
8.82532 8.82532 8.82532 8.82532
reviews_per_month estimated_revenue_l365d
8.82532 7.71759
# 4. Variables clave Room Type y Precios
# Ver si hay categorías raras antes de limpiar
print("--- Categorías únicas en Room Type ---")
[1] "--- Categorías únicas en Room Type ---"
print(table(listings$room_type))
Entire home/apt Hotel room Private room Shared room
6993 21 1182 19
# Ver formato del precio por si hay que limpiar
print("--- Ejemplo de Precios en crudo ---")
[1] "--- Ejemplo de Precios en crudo ---"
print(head(listings$price))
[1] "$93.00" "$120.00" "$89.00" "" "$146.00" ""
En este punto hemos realizado un EDA pequeño, para hacernos una idea de donde partíamos, con qué tipo de datos tratamos, formato, valores nulos, incongruencias, en definitiva, qué sobra y qué nos parece interesante tratar y preprocesar en detalle.
library(ggplot2)
library(dplyr)
# GRÁFICO 1: EL "MONSTRUO" DE LOS PRECIOS
# Necesitamos convertir el precio a número "en sucio" solo para poder pintarlo
# (Sin modificar el dataset original todavía)
precios_sucios <- as.numeric(gsub("[\\$,]", "", listings$price))
# Creamos un dataframe temporal solo para este gráfico
df_plot_sucio <- data.frame(precio = precios_sucios)
ggplot(df_plot_sucio, aes(x = precio)) +
geom_histogram(binwidth = 50, fill = "red", color = "black", alpha = 0.6) +
theme_minimal() +
labs(
title = "Distribución Original de Precios (Sin Limpiar)",
subtitle = "Extrema asimetría debido a outliers (pisos de 5.000€+)",
x = "Precio (Crudo)",
y = "Frecuencia"
) +
annotate("text", x = 4000, y = 500, label = "Outliers Extremos\n(Distorsionan el modelo)", color = "red", fontface="bold")
# GRÁFICO 2: MAPA DE CALOR DE DATOS FALTANTES (NULOS)
# Calculamos el % de nulos por columna
na_summary <- data.frame(
columna = names(listings),
na_pct = colSums(is.na(listings)) / nrow(listings) * 100
) %>%
filter(na_pct > 0) %>% # Solo mostramos las que tienen nulos
arrange(desc(na_pct)) # Ordenamos de mayor a menor
# Pintamos solo las columnas problemáticas
ggplot(na_summary, aes(x = reorder(columna, na_pct), y = na_pct)) +
geom_bar(stat = "identity", fill = "orange", width = 0.7) +
coord_flip() + # Giramos para leer los nombres
theme_minimal() +
labs(
title = "Porcentaje de Valores Nulos por Variable",
subtitle = "Variables como 'calendar_updated' está vacía",
x = "Variable",
y = "% de Nulos"
) +
geom_text(aes(label = round(na_pct, 1)), hjust = -0.2, size = 3)
Tras este pequeño análisis decidimos seleccionar las columnas que nos impactan. Así, podemos centrarnos en analizar profundamente solo las variables necesarias, y hacer el preprocesado detallado y centrado en nuestro caso concreto y no extendernos en algo que nos nos dará fruto para resolver nuestra hipótesis planteadas.
# 1. SELECCIÓN DE COLUMNAS
df_raw_selected <- listings %>%
select(
# Identificador
id,
# Variables clave (Precios, Tipo y Estancia)
price,
room_type,
accommodates,
minimum_nights,
# Variables estructurales
bathrooms_text,
bedrooms,
amenities,
# Ubicación
neighbourhood_cleansed,
latitude,
longitude,
# Actividad y reputación
reviews_per_month,
number_of_reviews,
review_scores_rating,
review_scores_cleanliness,
review_scores_location,
# Gestión
availability_365,
calculated_host_listings_count,
instant_bookable
)
print(paste("Columnas seleccionadas:", ncol(df_raw_selected)))
[1] "Columnas seleccionadas: 19"
df_final <- df_raw_selected %>%
# --- 1. LIMPIEZA DE PRECIO ---
mutate(
price_clean = as.numeric(gsub("[\\$,]", "", price))
) %>%
# Filtrar precios coherentes
filter(price_clean > 10 & price_clean < 1000) %>%
# --- 2. LIMPIEZA DE BAÑOS (VERSIÓN MEJORADA) ---
mutate(
# 2.1. Sacamos el NÚMERO (Cantidad)
bathrooms_qty = as.numeric(str_extract(bathrooms_text, "\\d+(\\.\\d+)?")),
bathrooms_qty = ifelse(is.na(bathrooms_qty) & str_detect(bathrooms_text, "Half-bath"), 0.5, bathrooms_qty),
bathrooms_qty = ifelse(is.na(bathrooms_qty), 1, bathrooms_qty),
# 2.2. Sacamos la PRIVACIDAD (Calidad)
# Si detecta "shared", pone un 1. Si no, un 0.
bath_shared = ifelse(str_detect(bathrooms_text, regex("shared", ignore_case = TRUE)), 1, 0)
) %>%
# --- 3. INGENIERÍA DE AMENITIES ---
mutate(
amenities_count = str_count(amenities, ",") + 1,
has_pool = as.integer(str_detect(amenities, regex("pool", ignore_case = TRUE))),
has_ac = as.integer(str_detect(amenities, regex("air conditioning|ac", ignore_case = TRUE)))
) %>%
# --- 4. TRATAMIENTO DE NULOS ---
mutate(
reviews_per_month = replace_na(reviews_per_month, 0),
bedrooms = ifelse(is.na(bedrooms), median(bedrooms, na.rm = TRUE), bedrooms),
review_scores_rating = ifelse(is.na(review_scores_rating), mean(review_scores_rating, na.rm = TRUE), review_scores_rating),
review_scores_cleanliness = ifelse(is.na(review_scores_cleanliness), mean(review_scores_cleanliness, na.rm = TRUE), review_scores_cleanliness),
review_scores_location = ifelse(is.na(review_scores_location), mean(review_scores_location, na.rm = TRUE), review_scores_location),
instant_bookable_binary = ifelse(instant_bookable == "t", 1, 0),
# LIMPIEZA DE MINIMUM_NIGHTS
minimum_nights = ifelse(is.na(minimum_nights), 1, minimum_nights)
) %>%
# --- 5. SELECCIÓN FINAL ---
select(
id,
price = price_clean,
room_type,
accommodates,
minimum_nights,
bedrooms,
bathrooms = bathrooms_qty,
bath_shared,
amenities_count, has_pool, has_ac,
neighbourhood_cleansed,
latitude, longitude,
reviews_per_month, number_of_reviews,
review_scores_rating, review_scores_cleanliness, review_scores_location,
availability_365, calculated_host_listings_count,
instant_bookable = instant_bookable_binary
) %>%
# --- 6. CONVERSIÓN A FACTORES (LO NUEVO) ---
# Convertimos texto y binarios a categorías para que R los entienda bien en los gráficos
mutate(
room_type = as.factor(room_type),
neighbourhood_cleansed = as.factor(neighbourhood_cleansed)
)
# Verificar estructura final
glimpse(df_final)
Rows: 7,484
Columns: 22
$ id <dbl> 49287, 108236, 111140, 179629, 207702, 227905, 253430, 266016, 298673, 315971…
$ price <dbl> 93, 120, 89, 146, 85, 54, 80, 105, 127, 49, 103, 104, 132, 63, 116, 89, 80, 4…
$ room_type <fct> Entire home/apt, Entire home/apt, Entire home/apt, Entire home/apt, Entire ho…
$ accommodates <int> 3, 5, 4, 5, 2, 1, 2, 6, 2, 1, 4, 2, 4, 2, 4, 4, 4, 5, 2, 10, 3, 4, 4, 4, 2, 4…
$ minimum_nights <int> 3, 2, 2, 3, 3, 1, 3, 2, 2, 3, 1, 3, 2, 2, 1, 3, 3, 2, 3, 2, 3, 4, 1, 1, 15, 1…
$ bedrooms <dbl> 1, 2, 1, 3, 1, 1, 1, 2, 1, 1, 2, 1, 2, 1, 1, 1, 2, 3, 0, 3, 1, 1, 2, 1, 1, 1,…
$ bathrooms <dbl> 1.0, 1.0, 1.0, 1.0, 1.0, 1.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.5, 1.0, 1.0, 1.…
$ bath_shared <dbl> 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ amenities_count <dbl> 50, 63, 53, 29, 37, 25, 35, 28, 51, 39, 33, 46, 42, 20, 33, 37, 45, 23, 29, 2…
$ has_pool <int> 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ has_ac <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,…
$ neighbourhood_cleansed <fct> "San Lorenzo", "San Vicente", "San Vicente", "Santa Cruz", "Museo", "Museo", …
$ latitude <dbl> 37.39898, 37.39686, 37.39592, 37.38731, 37.38945, 37.39299, 37.39107, 37.3811…
$ longitude <dbl> -5.995330, -5.999127, -5.999317, -5.990950, -5.998890, -5.999160, -5.992610, …
$ reviews_per_month <dbl> 0.23, 1.27, 0.36, 1.23, 3.78, 1.41, 1.96, 2.76, 3.71, 3.14, 2.88, 4.19, 0.75,…
$ number_of_reviews <int> 40, 222, 63, 205, 508, 238, 331, 459, 611, 519, 474, 686, 58, 932, 417, 198, …
$ review_scores_rating <dbl> 4.64, 4.77, 4.70, 4.55, 4.87, 4.94, 4.78, 4.61, 4.87, 4.75, 4.75, 4.84, 4.60,…
$ review_scores_cleanliness <dbl> 4.97, 4.81, 4.69, 4.77, 4.95, 4.96, 4.68, 4.72, 4.91, 4.90, 4.89, 4.80, 4.64,…
$ review_scores_location <dbl> 4.92, 4.86, 4.82, 4.93, 4.94, 4.91, 4.93, 4.88, 4.96, 4.84, 4.64, 4.98, 4.83,…
$ availability_365 <int> 206, 79, 106, 245, 111, 62, 148, 32, 120, 60, 68, 227, 60, 191, 276, 12, 252,…
$ calculated_host_listings_count <int> 1, 4, 4, 24, 1, 2, 2, 5, 205, 2, 1, 1, 6, 2, 4, 1, 1, 1, 4, 9, 1, 15, 1, 15, …
$ instant_bookable <dbl> 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1,…
summary(df_final$minimum_nights)
Min. 1st Qu. Median Mean 3rd Qu. Max.
1.00 1.00 2.00 2.96 2.00 360.00
Para garantizar la robustez de los modelos posteriores (Clustering y
Regresión), hemos implementado una transformación sobre el dataset
original (df_raw_selected). Las operaciones realizadas son
las siguientes:
1. Limpieza y Filtrado de la Variable Objetivo
(Price) La variable precio original contenía
caracteres no numéricos ($ y ,). Se ha
convertido a tipo numérico y posterior filtrado.
2. Extracción de Información Estructural
(Bathrooms) La columna bathrooms_text
presentaba información no estructurada (ej. “1.5 baths”). Hemos
realizado las siguientes acciones:
Extraído el valor numérico.
Hemos imputado el valor 0.5 para los casos etiquetados como “Half-bath”.
Hemos asumido un valor estándar de 1 baño para los valores nulos restantes.
3. Características de Servicios
(Amenities) La columna amenities es
una lista de texto JSON compleja, muy interesante porque podemos recoger
qué características “deluxe” tienen los pisos. Para ello, hemos creado
tres nuevas variables sintéticas y asi cuantificar el valor añadido:
amenities_count: Conteo del número
total de servicios ofrecidos, como indicador general del nivel de
equipamiento.
has_pool y
has_ac: Variables binarias (1/0) creadas
mediante búsqueda de patrones de texto. Consideramos que en un sitio
como Sevilla, la piscina y el aire acondicionado son críticos para el
precio (Pregunta 2).
4. Estrategia seguida para la Imputación de Valores Nulos Para no perder observaciones valiosas, hemos aplicado una estrategia de imputación diferente, según cada variable:
Actividad (reviews_per_month): Los
nulos se imputan con 0, interpretando la ausencia de
dato como actividad nula.
Estructural (bedrooms): Se imputa
con la mediana, al ser una medida más robusta frente a
valores atípicos en variables de conteo entero.
Calidad (review_scores): Se imputa
con la media global para mantener la neutralidad de la
observación sin penalizarla artificialmente.
Noches minimas (minimum_nights): Se
imputan los nulos con 1, asumiendo la estancia mínima
estándar.
5. Selección y Renombrado Final : hemos generado el
dataset df_final descartando las variables intermedias
sucias (como bathrooms_text o el price
original) y reteniendo únicamente las 19 variables limpias y
transformadas que necesitaremos para trabajar los tres modelos
del proyecto.
head(df_final)
# --- 2.3. FILTRADO DE INCONSISTENCIAS Y OUTLIERS ---
# Partimos de df_final que ya tiene las variables limpias (baños, amenities, etc.)
df_eda <- df_final %>%
# A. Filtro de Precio: Ajustamos a tu rango (10€ - 1.000€)
filter(price > 10 & price < 1000) %>%
# B. Filtro Geoespacial: Seguridad por si falló algo
filter(!is.na(latitude) & !is.na(longitude)) %>%
# C. Filtro de Estancias: Eliminamos alquileres de año completo (posibles errores)
filter(minimum_nights < 365)
# Ver cuántas filas nos quedan vs las originales
print(paste("Observaciones originales:", nrow(df_final)))
[1] "Observaciones originales: 7484"
print(paste("Observaciones para análisis (df_eda):", nrow(df_eda)))
[1] "Observaciones para análisis (df_eda): 7484"
# 3.1.1 Distribución del Precio
ggplot(df_eda, aes(x = price)) +
geom_histogram(binwidth = 10, fill = "steelblue", color = "white") +
theme_minimal() +
labs(title = "Distribución de Precios (Filtrado 10€ - 1000€)",
subtitle = "La mayoría del mercado se concentra bajo los 200€",
x = "Precio (€)", y = "Frecuencia")
# 3.1.2 Conteo por Tipo de Habitación
ggplot(df_eda, aes(x = room_type, fill = room_type)) +
geom_bar() +
geom_text(stat='count', aes(label=..count..), vjust=-0.5) +
theme_minimal() +
scale_fill_brewer(palette = "Pastel1") +
labs(title = "Oferta por Tipo de Alojamiento") +
theme(legend.position = "none")
# 3.1.3 (NUEVO) Distribución de Servicios (Amenities Count)
# Importante para justificar que hay pisos "pelados" y pisos "equipados"
ggplot(df_eda, aes(x = amenities_count)) +
geom_histogram(binwidth = 2, fill = "purple", alpha = 0.7) +
theme_minimal() +
labs(title = "Distribución de Equipamiento (Amenities)",
x = "Cantidad de Servicios", y = "Nº Alojamientos")
NA
NA
Aquí detectamos la Multicolinealidad
# 3.2.1 Matriz de Correlación (Usando las nuevas variables numéricas)
library(corrplot)
# Seleccionamos las numéricas clave del nuevo dataset
nums <- df_eda %>%
select(price, accommodates, bedrooms, bathrooms, # Tu variable limpia
amenities_count, minimum_nights, # Tus variables nuevas
number_of_reviews, reviews_per_month,
review_scores_rating) %>%
na.omit()
M <- cor(nums)
corrplot(M, method = "color", type = "upper",
addCoef.col = "black", number.cex = 0.7, # Tamaño letra números
tl.col = "black", tl.srt = 45,
diag = FALSE,
title = "Matriz de Correlación (Variables Finales)",
mar = c(0,0,1,0)) # Ajuste de margen para el título
NA
NA
# 3.2.2 Impacto del Baño Compartido
# Esto valida visualmente si 'bath_shared' afecta al precio
ggplot(df_eda, aes(x = as.factor(bath_shared), y = price, fill = as.factor(bath_shared))) +
geom_boxplot(alpha = 0.7) +
scale_x_discrete(labels = c("0" = "Privado", "1" = "Compartido")) +
scale_fill_manual(values = c("#00AFBB", "#FC4E07")) +
theme_minimal() +
ylim(0, 300) + # Zoom para ver mejor las cajas (ignorando mansiones)
labs(title = "Impacto del Baño Compartido en el Precio",
x = "Tipo de Baño", y = "Precio (€)", fill = "")
NA
NA
# 3.2.3 Precio vs Reviews (Rotación)
ggplot(df_eda, aes(x = reviews_per_month, y = price)) +
geom_point(alpha = 0.3, color = "darkblue") +
geom_smooth(method = "lm", color = "red", se = FALSE) +
theme_minimal() +
labs(title = "Relación Precio vs Rotación", x = "Reviews/mes", y = "Precio")
# 3.3.1 Mapa de Precios con la Giralda
ggplot(df_eda, aes(x = longitude, y = latitude, color = price)) +
geom_point(size = 0.8, alpha = 0.6) +
# AÑADIR LA GIRALDA - Punto de referencia
annotate("point", x = -5.99238, y = 37.38614, color = "yellow", size = 3, shape = 17) +
annotate("text", x = -5.99238, y = 37.38614, label = "Giralda", vjust = -1.5, color = "yellow", fontface = "bold", size=3) +
scale_color_viridis_c(option = "magma", direction = -1, limits = c(0, 300), oob = scales::squish) +
# limits=c(0,300) hace que los colores se centren en pisos normales, oob=squish pinta los caros del color máximo
coord_quickmap() +
theme_void() +
labs(title = "Mapa de Precios Sevilla Centro", color = "Precio")